跳至主要内容

課程:JavaScript 與 React 底層原理 第 1 堂:JS 執行模型基礎

03 Scope 與 Scope Chain

在上一節中,我們觀察到 JavaScript 引擎如何透過 Call Stack 來管理函式的執行順序。每當一個函式被呼叫,一個新的執行環境(Execution Context, EC)就會被推入堆疊。然而,這裡有一個隱藏的關鍵問題:當程式碼在執行環境中運行並遇到一個變數時,它如何知道這個變數代表什麼?

如果我們在不同的地方定義了同名的變數,JavaScript 會選擇哪一個?這種「尋找變數」的規則,就是我們這一節要探討的核心:Scope(作用域)Scope Chain(作用域鏈)


誰決定了變數的歸屬?

讓我們從一個簡單的「直覺測試」開始。請預測以下程式碼會印出什麼:

const name = "全域小強";

function second() {
console.log(name);
}

function first() {
const name = "區域小明";
second();
}

first();

你認為結果會是「區域小明」還是「全域小強」?

如果你直覺認為 second() 是在 first() 裡面被呼叫的,而 first() 裡面正好有一個 name 變數,所以應該印出「區域小明」,那麼你可能掉進了「動態作用域(Dynamic Scope)」的陷阱。

正確答案是:「全域小強」。

這個結果揭示了 JavaScript 最重要的一個特性:詞法作用域(Lexical Scope)

詞法作用域:位置決定一切

「詞法(Lexical)」這個詞在程式語言中通常指「撰寫時」或「編譯時」。

詞法作用域(Lexical Scope) 指的是:變數的作用域是在你寫下程式碼的那一刻,根據函式定義在原始碼中的物理位置來決定的。它與函式「在哪裡被呼叫」完全無關,只與函式「在哪裡被定義」有關。

在上面的例子中:

  1. second 函式是在全域環境中被定義的。
  2. 對於 second 來說,它的「外面」就是全域環境。
  3. 即使它被 first 呼叫,它的靈魂(作用域)依然與全域綁定。

這種設計讓程式碼的行為變得極其可預測。只要你看著原始碼,不需要執行它,就能判斷出某個變數會指向哪裡。這也是為什麼我們常說 JavaScript 的作用域是「靜態」的。


作用域的層級結構

在 JavaScript 中,作用域並不是扁平的,而是存在層級之分。主要可以分為以下三類:

全域作用域(Global Scope)

這是最外層的作用域。在瀏覽器環境下,全域作用域就是 window 物件(在 Node.js 中則是 global)。在全域作用域中宣告的變數,在程式碼的任何地方都可以存取。

函式作用域(Function Scope)

每當你定義一個函式,你就建立了一個新的函式作用域。在函式內部宣告的變數,外部是無法存取的。這就是為什麼函式常被用來封裝私有資料,避免污染全域空間。

區塊作用域(Block Scope)

這是 ES6(2015 年)引入的重要特性。在使用 letconst 時,任何一對花括號 { ... }(例如 if 判斷式、for 迴圈)都會建立一個獨立的區塊作用域。

重要區分: 傳統的 var 並不具備區塊作用域,它只認函式。這也是為什麼現代 JavaScript 開發強烈建議使用 letconst 來避免意外的變數溢出。


作用域鏈(Scope Chain)的真實機制

我們已經知道每個執行環境(EC)都有一個 環境紀錄(Environment Record) 來儲存變數。但 EC 還有另一個關鍵欄位:外部環境參考(Outer Environment Reference)

這就是 作用域鏈(Scope Chain) 的物理基礎。當 JavaScript 引擎建立一個執行環境時,它會去查看這個函式在原始碼中「被寫在哪裡」,然後將該位置的外部環境指派給這個 Outer 參考。

變數尋找的演算法

當程式執行到 console.log(x) 時,引擎會啟動一個尋找流程:

  1. 當前作用域: 引擎先檢查目前執行中的 EC,其環境紀錄中是否有 x?如果有,就回傳並結束尋找。
  2. 外部環境: 如果找不到,引擎會順著 Outer Reference 往上一層作用域移動。
  3. 重複尋找: 在上一層的環境紀錄中繼續尋找 x
  4. 抵達全域: 這個過程會一直持續,直到找到變數,或者抵達 全域執行環境(Global EC)
  5. 報錯或失敗: 如果連全域環境都沒有 x
  • 若是「讀取」行為(RHS),會拋出 ReferenceError
  • 若是「賦值」行為(LHS)且不在嚴格模式下,則會在全域建立一個新變數(這是 JS 著名的雷點)。

圖解:鏈條是如何串聯的?

想像你有三層嵌套的函式:

const globalVar = "Global";

function outer() {
const outerVar = "Outer";

function inner() {
const innerVar = "Inner";
console.log(innerVar, outerVar, globalVar);
}

inner();
}

outer();

當執行到 inner() 內部時,Call Stack 與 Scope Chain 的狀態如下:

  1. Inner EC:
  • 變數:innerVar
  • Outer:指向 outer 的 EC
  1. Outer EC:
  • 變數:outerVar
  • Outer:指向全域 EC
  1. Global EC:
  • 變數:globalVar
  • Outer:null(鏈條終點)

inner 試圖讀取 outerVar 時,它在自己的環境找不到,於是透過 Outer 指針跳到 outer 的環境並成功獲取。這種由內向外的單向查找路徑,就是 Scope Chain。


遮蔽效應(Variable Shadowing)

在作用域鏈中,如果內層作用域宣告了與外層同名的變數,會發生什麼事?

const fruit = "蘋果";

function eat() {
const fruit = "香蕉";
console.log("正在吃:", fruit);
}

eat();
console.log("籃子裡還有:", fruit);

執行結果:

  • 正在吃:香蕉
  • 籃子裡還有:蘋果

這稱為 遮蔽效應(Shadowing)。因為引擎是由內向外找,一旦在內層找到了 fruit(香蕉),它就心滿意足地結束了尋找任務,永遠不會看到外層的「蘋果」。這並不會改變外層變數的值,只是在該作用域內暫時「遮住」了它。


為什麼外層看不到內層?

這是一個常見的學生問題:「為什麼我可以從裡面看外面,但不能從外面看裡面?」

這必須回到 執行環境的生命週期。 當一個內層函式執行時,外層函式必然還在 Call Stack 中(或者至少其作用域已經被保留,我們會在 Closure 章節細講)。但當程式碼執行在「外層」時,內層函式甚至還沒有被呼叫,它的執行環境根本不存在。

更本質的原因是:Scope Chain 是單向的鏈結串列(Singly Linked List)。每個環境只有一個 Outer 指針指向外面,並沒有 Inner 指針指向裡面。


React 中的 Scope Chain:實踐與連結

理解了 Scope Chain,你就能解開 React 開發中許多「理所當然」但細思極恐的行為。

為什麼 Event Handler 可以存取 Props?

看看這個標準的 React 元件:

const UserProfile = ({ name }) => {
const [age, setAge] = useState(20);

const handleClick = () => {
// 這裡為什麼可以存取到 name 和 age?
console.log(`Hello ${name}, you are ${age} years old.`);
};

return <button onClick={handleClick}>點我</button>;
};

這背後的 JS 原理就是 Scope Chain:

  1. UserProfile 是一個函式,當它被 React 呼叫(渲染)時,會建立一個執行環境(EC)。
  2. name(從 props 解構)和 age(狀態)都存在於 UserProfile 的環境紀錄中。
  3. handleClick 是在 UserProfile 內部定義的函式。
  4. 根據詞法作用域,handleClickOuter Reference 會指向 UserProfile 的環境。
  5. 因此,當按鈕被點擊、handleClick 執行時,它能透過 Scope Chain 輕易地找到外層的 nameage

這就是為什麼我們不需要透過參數傳遞,就能在元件內部的各種小函式中直接使用狀態。

預覽:閉包(Closure)的萌芽

如果你把 handleClick 傳遞給一個子元件,甚至是一個非同步的 setTimeout,過了 5 秒後執行,它依然能找到當初那個 name

這聽起來很神奇,因為 5 秒後 UserProfile 函式早就執行完了(理論上它的 EC 應該從 Stack 中彈出了)。為什麼變數還在?

這就是我們下一個主題 Closure(閉包) 的核心:當一個函式「記住」了它的 Scope Chain,並且被帶到了它的出生地之外執行時,它會強行把那個 Scope Chain 留下來,不讓垃圾回收機制(Garbage Collection)把它清掉。


總結與回顧

掌握 Scope Chain 是從「寫程式碼」進化到「理解程式碼執行」的關鍵一步。

  • Lexical Scope:決定作用域的是「定義位置」,不是「呼叫位置」。
  • Outer Reference:每個環境都有一個指針指向它的物理外層,串聯成鏈。
  • 單向查找:由內而外,找不到就去全域找,再找不到就報錯。
  • 遮蔽效應:內層變數會遮住同名的外層變數。

透過這三個部分(執行環境、Call Stack、Scope Chain),我們已經建立了一個完整的 JavaScript 執行靜態模型。我們知道環境如何建立、呼叫如何堆疊,以及變數如何尋找。

在下一節中,我們將探討這個模型中一個有趣的現象:Hoisting(提升)。為什麼有些變數在宣告之前就能被存取?這背後的機制其實就在我們剛學過的執行環境「建立階段」中。最後,我們也會透過一個綜合練習,手動追蹤一段複雜的程式碼,將目前學到的所有概念融會貫通。

重點提醒: 請務必記住 Scope Chain 是在函式建立時就決定好的,這個觀念將會是你理解 React Hooks(特別是處理 Stale Closure 這種棘手 bug)時最重要的武器。我們下節課見。